Skip to content

fix(link): cancel in-flight <Link> prefetch setup on cache invalidation and scope navigation cancellation to its destination - #2737

Open
NathanDrake2406 wants to merge 5 commits into
cloudflare:mainfrom
NathanDrake2406:fix/link-prefetch-setup-registry
Open

fix(link): cancel in-flight <Link> prefetch setup on cache invalidation and scope navigation cancellation to its destination#2737
NathanDrake2406 wants to merge 5 commits into
cloudflare:mainfrom
NathanDrake2406:fix/link-prefetch-setup-registry

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Overview

Goal Give <Link> prefetch setup the same cancellation semantics router.prefetch() gained in #2709
Core change Replace linkPrefetchNavigationEpoch with the sticky, destination-scoped pending-setup registry, moved to a shared dependency-free module
Key boundary shims/internal/app-prefetch-setup.ts owns registration and cancellation; navigation.ts owns the policy of when to cancel
Expected impact router.refresh() can no longer be undone by a late <Link> prefetch; navigations no longer drop unrelated pending prefetches

Closes #2718.

Why

A prefetch whose asynchronous setup is still in flight must not commit state from a cache generation that has since been invalidated, and a navigation should only cancel prefetch setup it is about to duplicate. #2709 built both guarantees for router.prefetch() as a registry of sticky per-destination tokens, but <Link>'s prefetchUrl kept its own global counter with neither property:

Area Principle / invariant What this PR changes
Cache invalidation invalidatePrefetchCache() voids the whole generation, so every pending setup must die with it The counter only advanced on navigation start, so a <Link> setup awaiting its lazy module imports resumed after router.refresh() and repopulated a navigation-reusable entry from the pre-refresh generation
Navigation start Cancel only setup for the route the navigation will fetch itself; anything broader turns prefetches into timing lottery Any navigation bumped the single global counter, so navigating to /b silently dropped a pending prefetch for /a that nothing else would ever fetch
Module layering link.tsx loads navigation.ts lazily to keep the navigation runtime off Link's synchronous startup path The registry now lives in a React-free, route-trie-free shims/internal/ module both files import statically, following the layering app-route-prefetch-policy.ts established in #2709

Sticky per-token cancellation is what makes destination scoping sound: a navigation to /a followed by one to /b leaves a pending /a prefetch cancelled, where comparing against a "current destination" value would not.

What changed

Scenario Before After
router.refresh() while a <Link> prefetch awaits its module imports Setup resumes, fetches, and registers a pre-refresh cache entry, undoing the refresh for that route Setup is cancelled; visible links still re-prefetch the fresh generation via the existing invalidation ping
Navigation to the same route as a pending <Link> prefetch Cancelled (global counter) Cancelled (destination match), unchanged behaviour
Navigation elsewhere while a <Link> prefetch is pending Prefetch silently dropped; later navigation pays full fetch cost Prefetch completes and stays reusable
Raw history.pushState (no request issued) Dropped all pending <Link> prefetches Leaves them alone, matching the router-side rationale in notifyAppNavigationStart
router.prefetch() (all #2709 scenarios) Registry in navigation.ts Same behaviour through the shared module; tests/prefetch-cache.test.ts unchanged and green
Maintainer review path
  1. packages/vinext/src/shims/internal/app-prefetch-setup.ts for the moved registry and the layering constraint its doc comment states (why it must stay dependency-free).
  2. packages/vinext/src/shims/navigation.ts for the mechanical switch to imports; cancellation policy call sites (notifyAppNavigationStart, invalidatePrefetchCache) are unchanged.
  3. packages/vinext/src/shims/link.tsx for the substantive change: prefetchUrl registers a token synchronously before its first await, re-checks cancelled after the module-loading race and again after the RSC-URL awaits before any cache write, and unregisters in finally. The navigation-runtime hook reverts to plain notifyLinkNavigationStart since cancellation no longer rides it.
  4. tests/link-navigation.test.ts for the three regression shapes.
Validation
  • Three new tests mirror the router-side shapes from fix(navigation): reuse router.prefetch payloads during navigation #2709, driven through prefetchUrl via the renderIsolatedLink harness:
    • "does not resume a Link prefetch across a cache invalidation" uses a hover-intent prefetch because invalidation deliberately re-pings visible links to re-prefetch fresh data, which would mask the stale resume under test.
    • "cancels a pending Link prefetch superseded by a navigation to the same route" and "keeps a pending Link prefetch when the navigation goes elsewhere" pin the destination scoping as a pair; the first also passes under a global counter.
  • Verified the tests fail on the pre-change implementation for exactly the two defects: the invalidation test and the navigate-elsewhere test fail, the same-route test passes.
  • vp test run green on the three directly affected suites (263 tests) and on the 17 suites importing the navigation or link shims (1,986 tests); vp check clean on all changed files; pre-commit full check and knip passed.
Risk / compatibility
  • No public API change; the registry module is shims/internal/ and the removed counter was module-private.
  • Bundle layering preserved: the new module's only imports (url-utils, utils/external-url) were already on Link's synchronous path.
  • Deliberate behaviour change: navigations and raw history updates no longer cancel <Link> prefetches for other destinations. This trades a possible duplicate request in zero scenarios (nothing else fetches those routes) for prefetch reliability, matching the tradeoff already accepted for router.prefetch() in fix(navigation): reuse router.prefetch payloads during navigation #2709.

References

Reference Why it matters
Issue #2718 Both defects, the layering constraint, and the suggested shape this PR follows
PR #2709 Introduced the sticky destination-scoped registry for router.prefetch(); its doc comment deferred this unification
tests/prefetch-cache.test.ts Router-side analogues of the three regression shapes, unchanged by this PR

…on and scope navigation cancellation to its destination

A <Link> prefetch whose setup was still awaiting its module imports survived
router.refresh(): the invalidation never advanced linkPrefetchNavigationEpoch,
so the resumed closure repopulated a navigation-reusable entry from the
pre-refresh cache generation. The same global counter also cancelled a pending
prefetch for /a when a navigation went to /b, making viewport and intent
prefetches timing-dependent for no duplicate-request benefit.

Replace the counter with the sticky, destination-scoped pending-setup registry
that cloudflare#2709 introduced for router.prefetch(), moved to a dependency-free
shims/internal/app-prefetch-setup.ts so link.tsx can register synchronously
without pulling navigation.ts onto its startup path.

Fixes cloudflare#2718
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@pkg-pr-new

pkg-pr-new Bot commented Jul 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2737
npm i https://pkg.pr.new/create-vinext-app@2737
npm i https://pkg.pr.new/@vinext/types@2737
npm i https://pkg.pr.new/vinext@2737

commit: a1e2bd2

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared a1e2bd2 against base 25dc2f3 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 134.3 KB 134.4 KB ⚫ +0.1%
Client entry size (gzip) vinext 121.9 KB 121.9 KB ⚫ +0.0%
Dev server cold start vinext 2.96 s 2.94 s ⚫ -0.6%
Production build time vinext 3.23 s 3.21 s ⚫ -0.7%
RSC entry closure size (gzip) vinext 111.3 KB 111.3 KB ⚫ +0.0%
Server bundle size (gzip) vinext 189.0 KB 189.0 KB ⚫ -0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4e8e71ba7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/shims/link.tsx Outdated
Comment thread packages/vinext/src/shims/link.tsx Outdated
…ld the setup token through staged shell work

Codex review on cloudflare#2737 found two gaps in the registry wiring:

- The token destination was derived from the basePath-prefixed browser href,
  which toAppPrefetchDestination prefixes again, so under a non-empty basePath
  no navigation key ever matched and same-route cancellation silently stopped
  working. Derive it from the app-relative prefetchHref, matching what
  navigations pass in.

- The outer closure unregistered the token while the fire-and-forget loading
  shell (and the staged shell-first payload) were still awaiting
  createRscRequestUrl, so an invalidation in that window found no token and
  the helper repopulated the invalidated cache. Hold the token until staged
  work settles and re-check cancellation inside the helpers before their
  cache writes.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 609a0538d4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

… boundary

Replace the module mock that gated createRscRequestUrl with timing derived
from the stubbed fetch itself: a high-priority intent prefetch issues its
full-payload fetch synchronously inside the setup closure, so a microtask
scheduled from that call lands after the closure returns and before the shell
helper's digest-backed createRscRequestUrl resolves. No internal collaborators
are mocked.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 2ba21e39f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: LGTM

Reviewed the full diff, ran the affected suites, and traced the cancellation-guard flow against the router.prefetch() reference in navigation.ts. This is a clean, well-motivated refactor and I don't see any correctness issues.

What I verified

Behavior parity with router.prefetch() (#2709). The shared registry (beginPrefetchSetup/cancelPendingPrefetchSetups/finishPrefetchSetup/toAppPrefetchDestination) is a verbatim move out of navigation.ts, and both call sites (notifyAppNavigationStart destination-scoped, invalidatePrefetchCache wholesale via cancelPendingPrefetchSetups(null)) are unchanged. tests/prefetch-cache.test.ts (67) stays green, confirming no router-side regression.

Layering constraint holds. app-prefetch-setup.ts only imports url-utils.js (→ base-path.js) and external-url.js (no imports). No React, no route-trie, no cycle back into navigation.ts/link.tsx. The module can be statically imported on Link's synchronous startup path without pulling in the navigation runtime, as the doc comment claims. Benchmarks confirm zero bundle-size movement.

Cancellation guards cover every cache-write boundary in link.tsx:

  • Token registered before the first await (line 436), re-checked after module loading (462) and after the RSC-URL awaits before the main cache write (554). The main write path (554→788) has no intervening await, so no cancellation can land in that window — correct.
  • fetchLoadingShellForReuse re-checks after its own createRscRequestUrl await (594) before its shell write.
  • The route-tree gated closure guards only its side write (719) while still resolving the pre-registered navigation entry — the comment at 715-718 correctly explains why.
  • The finally holds the token via Promise.allSettled(stagedSetupWork) until the fire-and-forget shell and staged shell-first payload settle, closing the window the first Codex P2 flagged.

basePath token derivation is now correct. The Link token uses toAppPrefetchDestination(prefetchHref) (app-relative), matching router.prefetch()'s toAppPrefetchDestination(prefetchHref) at navigation.ts:2561/2576, not the basePath-prefixed fullHref. This resolves the second Codex P2. The ?? fullHref fallback is genuinely unreachable: getLinkPrefetchHref already returns null for cross-origin hrefs, and the code runs client-side inside hasAppNavigationRuntime(), so toAppPrefetchDestination never returns null here.

Behavior-change surface is sound. Reverting notifyLinkNavigationStart to not cancel (dropping the global epoch) is the whole point — raw history.pushState no longer drops unrelated pending prefetches, matching the router-side rationale in notifyAppNavigationStart. No dangling references to linkPrefetchNavigationEpoch/navigationEpoch remain.

Checks

  • vitest run tests/link-navigation.test.ts → 79 passed
  • vitest run tests/prefetch-cache.test.ts → 67 passed
  • vp lint on all three source files → clean

The five new regression tests each pin a distinct invariant (stale resume across invalidation, same-route vs. elsewhere scoping as a pair, basePath key derivation, staged-shell leak) and the PR notes they were verified to fail on the pre-change implementation. Nice work.

@ask-bonk

ask-bonk Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's my summary:

Review complete — LGTM

I reviewed PR #2737 (fork, review-only mode), read all four changed files, traced the cancellation flow against the router.prefetch() reference implementation, and ran the affected test suites. No issues found.

Key findings

The refactor is sound. The prefetch-setup registry is a verbatim move from navigation.ts into the new dependency-free shims/internal/app-prefetch-setup.ts. Both call sites in navigation.ts (notifyAppNavigationStart, invalidatePrefetchCache) are unchanged, and tests/prefetch-cache.test.ts (67 tests) stays green.

Layering constraint holds. The new module imports only url-utils.js and external-url.js — no React, no route-trie, no cycle. It's safe to static-import on Link's startup path.

Cancellation guards cover every cache-write boundary in link.tsx: registered before the first await, re-checked after module loading, after the RSC-URL awaits, inside fetchLoadingShellForReuse, and in the route-tree gated closure's side-write. The finally holds the token via Promise.allSettled(stagedSetupWork) until staged helpers settle.

Both Codex P2 issues were properly fixed:

  • Staged loading-shell leak → token now held through all staged work.
  • basePath double-prefixing → token derived from app-relative prefetchHref, matching router.prefetch().

The ?? fullHref fallback is genuinely unreachable (client-side, same-origin prefetchHref), consistent with the doc comment.

Verification

  • tests/link-navigation.test.ts → 79 passed
  • tests/prefetch-cache.test.ts → 67 passed
  • vp lint on all three source files → clean

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unify prefetch-setup cancellation: <Link> misses cache invalidation and is not destination-scoped

2 participants